Skip to content

Fix file-descriptor leak in ProcessManager.restart_process - #70185

Open
dwoz wants to merge 1 commit into
saltstack:masterfrom
dwoz:dwoz/fix/master-fileserver-update-fd-leak
Open

Fix file-descriptor leak in ProcessManager.restart_process#70185
dwoz wants to merge 1 commit into
saltstack:masterfrom
dwoz:dwoz/fix/master-fileserver-update-fd-leak

Conversation

@dwoz

@dwoz dwoz commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Motivation

Prometheus scraping of the local salt-master container showed steady growth in
salt_master_process_fds{process="FileserverUpdate"} over two independent
multi-hour windows:

  • Window 1: 92 -> 132 (+40) in 12.75 h, ~+3.1 FD/hr
  • Window 2 (post-restart segment): 52 -> 76 (+24) in 10 h, ~+2.4 FD/hr

The same +4 FD-per-hour step pattern appeared on the parent-process series
salt_master_process_fds{process="master-main"} (95 -> 131 across the same
window) confirming the leak lives in the supervising parent, not in
FileserverUpdate itself. The step cadence matched
fileserver_interval=3600s exactly -- one step per subprocess-restart
cycle.

Root cause

salt.utils.process.ProcessManager.restart_process (salt/utils/process.py:563)
drops the dead child's Process reference without calling
Process.close(). The dead multiprocessing.popen_fork.Popen object owns
the two parent-side pipe fds Popen._launch allocates via os.pipe()
(parent_r and parent_w), and those fds are only released when the
Popen is finalized.

SignalHandlingProcess.__new__ registers _setup_signals via
register_after_fork_method(instance), which appends
(_setup_signals, (instance,), {}) to self._after_fork_methods. That list
holds a strong reference back to the instance -- a reference cycle that
defeats deterministic finalization when the last ProcessManager reference
is dropped. Result: every subprocess restart leaks 2 pipe fds in the parent,
and every subsequent forked child inherits the accumulated set.

Fix

Call Process.close() on the dead child in restart_process() after
starting its replacement, before removing the entry from _process_map.
This releases the Popen sentinel pipe deterministically regardless of the
reference cycle. Guarded with a defensive try/except (ValueError, AttributeError) per the Process.close() contract.

The same leak affected every long-lived master subprocess the
ProcessManager restarts (Maintenance, EventReturn, MWorker, ...); the
fix is in the shared supervisor code path, so all of them benefit.

Test

tests/pytests/functional/utils/test_process_restart_fd_leak.py::test_restart_process_does_not_leak_pipe_fds

Drives ProcessManager.restart_process() directly against a short-lived
SignalHandlingProcess and asserts the parent's /proc/<pid>/fd count
stays flat across 20 restarts.

  • Without the fix: AssertionError: ProcessManager.restart_process leaked 40 fds across 20 restarts (baseline=26, after=66) -- exactly +2 * iterations.
  • With the fix: PASSED (delta = 0).

Testing done

  • pytest tests/pytests/functional/utils/test_process_restart_fd_leak.py -- fails on baseline, passes with fix
  • pytest tests/pytests/functional/utils/test_process.py -- all 7 pass (including the pre-existing test_subprocess_list_fds and test_process_manager_60749)
  • pytest tests/unit/utils/test_process.py -- all 21 pass (6 skipped)
  • pytest tests/pytests/unit/test_master.py -- all 43 pass (25 skipped)
  • pre-commit run --files salt/utils/process.py tests/pytests/functional/utils/test_process_restart_fd_leak.py changelog/* -- clean

@dwoz
dwoz requested a review from a team as a code owner August 29, 2026 02:54
Every time a salt-master supervised subprocess exits and is restarted
(FileserverUpdate on the fileserver_interval cycle, Maintenance on the
maintenance_interval cycle, etc.), ProcessManager.restart_process was
dropping the dead child's Process reference without calling
Process.close().  The dead multiprocessing.popen_fork.Popen object owns
the two parent-side pipe fds opened in Popen._launch (parent_r,
parent_w), and those fds are only released when the Popen is
finalized.  Because SignalHandlingProcess.__new__ registers
_setup_signals via register_after_fork_method(instance), the resulting
_after_fork_methods list holds a strong reference back to the instance
-- a reference cycle that defeats deterministic finalization of the
Popen when the last ProcessManager reference is dropped.

Net effect on a stock master with fileserver_interval=3600: the parent
leaks ~+2 pipe fds per subprocess restart per cycle, and every new
forked child inherits the accumulated set.  Measured on a running
local salt-master container over two independent multi-hour windows in
Prometheus (salt_master_process_fds{process="FileserverUpdate"}): 92
-> 132 in 12.75h and 52 -> 76 in 10h, i.e. +4 fds per hour aligned
with the FileserverUpdate restart cycle.  Same pattern on the
master-main process series (95 -> 131 across the same window),
confirming the leak is in the supervising parent, not the child.

Fix: call Process.close() on the dead child in restart_process()
after starting its replacement but before removing the entry from
_process_map, releasing the Popen sentinel pipe.

Regression test drives restart_process() directly against a
QuickSignalProc SignalHandlingProcess and asserts the parent's fd
count stays flat across 20 restarts.  Without this fix the test fails
with delta=40 fds leaked; with it, delta=0.

Refs: salt/utils/process.py:563 (restart_process),
      salt/master.py:692 (FileserverUpdate).
@dwoz
dwoz force-pushed the dwoz/fix/master-fileserver-update-fd-leak branch from 480ade0 to 093222f Compare August 29, 2026 02:55
@dwoz dwoz added this to the Potassium v3009.0 milestone Aug 29, 2026
@dwoz dwoz added the test:full Run the full test suite label Aug 29, 2026
@dwoz

dwoz commented Aug 29, 2026

Copy link
Copy Markdown
Contributor Author

Confirming this fix covers the Maintenance FD leak as well.

Independently reproduced the same drift on a stock master container: Prometheus salt_master_process_fds{process="Maintenance"} grew +4 FDs/hr for 12h straight, transitions aligned exactly with maintenance_interval=3600 (10:07 → 74, 11:07 → 78, 12:07 → 82, ...). MainProcess and FileserverUpdate showed the identical +4/hr pattern at the same timestamps — the tell that this is a single supervisor-level bug affecting every child ProcessManager restarts.

Root cause matches yours exactly: ProcessManager.restart_process() never calls .close() on the reaped Process, so the parent-side Popen.sentinel+parent_w pipe pair (2 fds) leaks per restart. Applied your branch head locally, verified my Maintenance-focused regression test flips FAIL→PASS:

No separate PR needed — closing my local branch as duplicate. Your test file (SignalHandlingProcess-based) already covers the exact class Maintenance uses, so no coverage gap.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

test:full Run the full test suite

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant